多 Tab 共享问题的本质
SPA 内部的单例模式只能保证同一个 JavaScript 执行环境中的实例唯一。但浏览器的每个标签页(Tab)都是独立的 JS 环境,拥有各自的全局对象和内存空间。静态变量无法跨 Tab 共享,因此每个新 Tab 都会创建新的 WebSocket 连接。
解决这个问题的思路很明确:需要一个多个 Tab 都能访问的共享空间来存放 WebSocket 实例。浏览器提供了几种跨 Tab 通信的方案:
| 方案 | 特点 | 适用场景 |
|---|---|---|
| BroadcastChannel API | Tab 之间互传数据 | 两个 Tab 之间的消息通知 |
| SharedWorker | 共享一个 Worker 线程,可存储实例 | 多 Tab 共享状态和连接 |
| localStorage + storage 事件 | 通过存储事件通信 | 简单的状态同步 |
SharedWorker 最适合当前场景——它可以在多个 Tab 之间共享一个后台线程,在这个线程中创建和管理唯一的 WebSocket 实例。
SharedWorker 基础
SharedWorker 是 Web Workers API 的一种,与普通 Worker(Dedicated Worker)不同,同一 URL 的 SharedWorker 在同源的多个上下文中只有一个实例。
创建 SharedWorker 文件
// shared-worker.js
const connections = {}
self.onconnect = function (event) {
const port = event.ports[0]
port.onmessage = function (event) {
const { type, data } = event.data
// 处理消息...
}
port.start() // 必须调用 start 开启消息通信
}
javascript
在前端使用 SharedWorker
// use-ws.ts
export function useWs(options: WebSocketClientOptions) {
const worker = new SharedWorker(
new URL('./shared-worker.js', import.meta.url),
{ type: 'module' }
)
worker.port.start()
worker.port.onmessage = (event) => {
console.log('收到 SharedWorker 消息:', event.data)
}
return {
init: () => {
worker.port.postMessage({ type: 'init', data: options })
},
send: (data: any) => {
worker.port.postMessage({ type: 'message', data })
},
close: () => {
worker.port.postMessage({ type: 'close' })
}
}
}
typescript
new URL('./shared-worker.js', import.meta.url) 是 Vite/Webpack 中引用 Worker 文件的固定写法,确保打包后的路径正确。
SharedWorker 的限制
SharedWorker 的 postMessage 有数据传递限制——只能传递可序列化的数据:
可以传递:简单对象、数组、数值、字符串、Date 对象、ArrayBuffer、Blob 等。
不能传递:函数、Error 对象、DOM 元素、WebSocket 实例等。
这意味着 options 中的 onOpen、onError、onClose、onMessage 这些回调函数无法直接传递给 SharedWorker。需要在发送前移除这些函数属性,然后通过消息事件机制在 SharedWorker 和各 Tab 之间间接实现回调效果。
TypeScript 编译问题
SharedWorker 文件必须是纯 JavaScript——浏览器不支持在 SharedWorker 中直接运行 TypeScript。需要将 WebSocketClient 类编译为 IIFE(立即执行函数表达式)格式的 JS 文件:
# 使用 esbuild 编译
npx esbuild src/utils/websocket-client.ts \
--format=iife \
--outfile=src/utils/websocket-client.global.js
bash
在 websocket-client.ts 文件末尾添加以下代码,将类暴露为全局对象:
// 将 WebSocketClient 挂载到全局对象 self 上,供 SharedWorker 使用
if (typeof self !== 'undefined') {
;(self as any).WebSocketClient = WebSocketClient
}
typescript
编译后在 SharedWorker 中通过 importScripts 引入:
// shared-worker.js
importScripts('./websocket-client.global.js')
// 现在可以使用全局的 WebSocketClient
const client = new WebSocketClient({ url: 'ws://localhost:3031' })
javascript
调试 SharedWorker
SharedWorker 运行在独立线程中,常规的开发者工具看不到它的日志。调试方法:
- 浏览器地址栏输入
chrome://inspect - 左侧选择 "Shared Workers"
- 找到对应的 SharedWorker 文件,点击 "inspect"
- 弹出独立的调试窗口,可以查看 console 输出、设置断点
这是 SharedWorker 开发中最容易踩的坑——明明代码执行了,但 console 里什么都没有,因为日志打印在了 SharedWorker 专属的调试窗口中。
设计思路总结
SharedWorker 充当的是 WebSocket 实例的"托管中心":所有 Tab 都通过 postMessage 与它通信,由它统一管理 WebSocket 连接的创建、消息收发和连接关闭。消息类型约定为 { type: 'init' | 'message' | 'close', data?: any } 这种简单的结构,便于在 SharedWorker 内部做事件分发。
↑